SPB Git forge

spb/countryatlas

Public
20commits 1branches 0releases
268.3 MBsize
maindefault branch
12 days agolast push
TypeScript 57% Python 38.6% JavaScript 3.6% CSS 0.6%
7.3 KB · 150 lines tsx
Raw Blame History
1import type { Metadata } from 'next';2import Link from 'next/link';3import { notFound } from 'next/navigation';4import { t } from '@/i18n';5import { api, isNotBuilt, isNotFound, safe } from '@/lib/api';6import { apiAnalytics } from '@/lib/api-analytics';7import { apiExplore } from '@/lib/api-explore';8import { regionShort } from '@/lib/regions';9import { jsonLd, jsonLdString, seoTitle } from '@/lib/seo';10import { routes } from '@/lib/site';11import { HEADLINE_TOPIC, type HEADLINE_INDICATORS } from '@/lib/topics';12import type { CountryResponse, FormatSpec } from '@/lib/types';13import { CountryHeader } from '@/components/country/country-header';14import { CountryStory } from '@/components/country/country-story';15import { DnaPanel } from '@/components/country/dna-panel';16import { KeyFacts } from '@/components/country/key-facts';17import { SimilarPanel } from '@/components/country/similar-panel';18import { Timeline } from '@/components/country/timeline';19import { CountryTopicsGrid } from '@/components/country/topics-grid';20import { ChangeList } from '@/components/data/change-list';21import { NotBuiltState } from '@/components/data/empty-state';22import { Metric, MetricGrid } from '@/components/data/metric';23import { Section } from '@/components/data/section';24import { TopicNav } from '@/components/data/topic-nav';2526export const revalidate = 900;2728type Params = { slug: string };2930async function loadCountry(slug: string): Promise<CountryResponse | 'not-built' | null> {31  try {32    return await api.country(slug);33  } catch (e) {34    if (isNotFound(e)) return null;35    if (isNotBuilt(e)) return 'not-built';36    throw e;37  }38}3940export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {41  const { slug } = await params;42  const data = await loadCountry(slug);43  if (!data || data === 'not-built') return { title: t('country.notFound'), robots: { index: false } };44  const name = data.country.name ?? slug;45  const title = seoTitle.country(name);46  const description = t('country.description', { name });47  const canonical = routes.country(data.country.slug ?? slug);48  return {49    title: { absolute: `${title} | ${t('site.name')}` },50    description,51    alternates: { canonical },52    openGraph: { title: `${title} | ${t('site.name')}`, description, url: canonical, type: 'article' },53    twitter: { card: 'summary_large_image', title, description },54  };55}5657export default async function CountryPage({ params }: { params: Promise<Params> }) {58  const { slug } = await params;59  const data = await loadCountry(slug);60  if (data === null) notFound();61  if (data === 'not-built') return <NotBuiltState />;6263  const c = data.country;64  const id = c.id;65  const name = c.name ?? id;66  const countryRef = { id, slug: c.slug ?? slug, name, flag: c.flag };67  // Optional panels in parallel; each tolerates failure independently.68  const [changes, similar, insights, dna, events, story, quality, indicators] = await Promise.all([69    safe(api.countryChanges(id, 8)),70    safe(api.countrySimilar(id, 'overall', 8)),71    safe(api.countryInsights(id)),72    safe(api.countryDna(id)),73    safe(api.countryEvents(id, 120)),74    safe(apiAnalytics.countryStory(id)),75    safe(apiAnalytics.countryQuality(id)),76    safe(apiExplore.indicators({ with_data: true })),77  ]);78  const counts = Object.fromEntries(data.topics.map((tp) => [tp.id, tp.n_with_data]));79  const withData = data.topics.reduce((a, tp) => a + tp.n_with_data, 0);80  const formats: Record<string, FormatSpec> = Object.fromEntries((indicators?.items ?? []).map((i) => [i.slug, { format: i.format, unit: i.unit, unit_short: i.unit_short, precision: i.precision, name: i.short_name ?? i.name, higher_is_better: i.higher_is_better }]));81  const ld = [jsonLd.country({ slug: c.slug ?? slug, name, iso3: c.iso3 ?? id, capital: c.capital }), jsonLd.breadcrumbs([{ name: t('nav.countries'), path: routes.countries() }, { name, path: routes.country(c.slug ?? slug) }])];8283  return (84    <>85      <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonLdString(ld) }} />86      <CountryHeader data={data} quality={quality} />87      <TopicNav slug={c.slug ?? slug} counts={counts} />8889      <Section id="headline" title={t('country.headline.title')} subtitle={t('country.headline.sub')} className="border-t-0">90        <MetricGrid>91          {data.headline.map((m) => {92            const topic = HEADLINE_TOPIC[m.indicator as (typeof HEADLINE_INDICATORS)[number]];93            return <Metric key={m.indicator} metric={m} country={countryRef} regionName={regionShort(c.region) ?? c.region_name} href={topic ? routes.countryIndicator(c.slug ?? slug, topic, m.indicator) : null} />;94          })}95        </MetricGrid>96      </Section>9798      {story && story.items.length ? (99        <Section id="story" title={t('country.story.title', { name })} subtitle={t('country.story.sub', { since: story.since ?? '', n: story.items.length })}>100          <CountryStory data={story} country={countryRef} />101        </Section>102      ) : null}103104      <div className="grid gap-x-10 lg:grid-cols-2">105        <Section id="changes" title={t('country.changes.title', { name })} subtitle={t('country.changes.sub')} actions={<Link href={`${routes.changes()}?country=${c.slug ?? id}`} className="text-accent hover:underline">{t('common.seeAll')} →</Link>}>106          <ChangeList items={changes?.items ?? []} />107        </Section>108        <Section id="similar" title={t('country.similar.title', { name })} subtitle={t('country.similar.sub')}>109          <SimilarPanel countryId={id} countrySlug={c.slug} countryName={name} initial={similar} formats={formats} />110        </Section>111      </div>112113      <div className="grid gap-x-10 lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]">114        <Section id="dna" title={t('country.dna.title')} subtitle={t('country.dna.sub')}>115          <DnaPanel countryId={id} name={name} initial={dna} />116        </Section>117        <Section id="facts" title={t('country.facts.title')} subtitle={t('country.facts.sub')}>118          <KeyFacts items={insights?.items ?? []} country={countryRef} />119          {c.languages?.length ? (120            <p className="mt-4 text-sm text-ink-2">121              <span className="text-ink-3">{t('country.languages')}: </span>122              {c.languages.join(', ')}123            </p>124          ) : null}125          {data.groups.length ? (126            <p className="mt-1 flex flex-wrap items-baseline gap-x-2 gap-y-1 text-sm text-ink-2">127              <span className="text-ink-3">{t('country.memberOf')}: </span>128              {data.groups129                .filter((g) => g.kind === 'org')130                .map((g) => (131                  <Link key={g.id} href={routes.region(g.slug ?? g.id)} className="link-quiet -my-2 inline-flex min-h-[44px] items-center py-2 md:my-0 md:min-h-0 md:py-0 text-ink hover:text-accent">132                    {g.name}133                  </Link>134                ))}135            </p>136          ) : null}137        </Section>138      </div>139140      <Section id="timeline" title={t('country.timeline.title')} subtitle={t('country.timeline.sub')}>141        <Timeline items={events?.items ?? []} slug={c.slug ?? slug} />142      </Section>143144      <Section id="topics" title={t('country.topics.title', { name })} subtitle={t('country.topics.sub', { n: data.topics.length, m: withData })}>145        <CountryTopicsGrid slug={c.slug ?? slug} topics={data.topics} />146      </Section>147    </>148  );149}150